跳到主要内容

SpringBoot 整合 Shiro

项目源码

直接使用 Cookie 和 Session 来进行认证~

配置环境

看网上的教程都是将如何自己配置 Shiro 整合的,实在是无语,搜索了一下果然有 Shiro 的启动器,官网地址 Integrating Apache Shiro into Spring-Boot Applications

<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>1.7.1</version>
</dependency>

Shiro 默认过滤器

Shiro 提供和多个默认的过滤器,我们可以用这些过滤器来配置过滤指定 url 的访问权限。也可以继承这些过滤器进行重写

记住这些过滤器,因为很多场景需要自己重写,例如跨域请求时就可以重写 UserFilter

权限控制的注解

Shiro 常用的权限控制注解,可以在控制器类上使用

注解功能
@RequiresGuest只有游客可以访问
@RequiresAuthentication需要登录才能访问
@RequiresUser已登录的用户或“记住我”的用户能访问
@RequiresRoles已登录的用户需具有指定的角色才能访问
@RequiresPermissions已登录的用户需具有指定的权限才能访问

搭建应用环境

创建用户实体

@Data
public class User {
private Long uid; // 用户id
private String uname; // 登录名,不可改
private String nick; // 用户昵称,可改
private String pwd; // 已加密的登录密码
private String salt; // 加密盐值
private Date created; // 创建时间
private Date updated; // 修改时间
private Set<String> roles = new HashSet<>(); //用户所有角色值,用于shiro做角色权限的判断
private Set<String> perms = new HashSet<>(); //用户所有权限值,用于shiro做资源权限的判断
}

编写测试 UserService

为其编写一个 UserService 模拟查询返回用户信息

@Service
public class UserService {

/**
* 模拟查询返回用户信息
*/
public User findUserByName(String uname) {
User user = new User();
user.setUname(uname);
user.setNick(uname + "NICK");
user.setPwd("J/ms7qTJtqmysekuY8/v1TAS+VKqXdH5sB7ulXZOWho="); //密码明文是123456
user.setSalt("wxKYXuTPST5SG0jMQzVPsg=="); //加密密码的盐值
user.setUid(new Random().nextLong()); //随机分配一个id
user.setCreated(new Date());
return user;
}
}

编写测试 RoleService

为其编写一个 RoleService 模拟查询返回角色信息,这个 UID 是上面用户信息传过来的

@Slf4j
@Service
public class RoleService {

/**
* 模拟根据用户 id 查询返回用户的所有角色,实际查询语句参考:
* SELECT r.rval FROM role r, user_role ur
* WHERE r.rid = ur.role_id AND ur.user_id = #{userId}
*
*/
public Set<String> getRolesByUserId(Long uid) {
log.info("打印的 uid 为:{}", uid.toString());

Set<String> roles = new HashSet<>();
//三种编程语言代表三种角色:js程序员、java程序员、c++程序员
roles.add("js");
roles.add("java");
roles.add("cpp");
return roles;
}
}

编写测试 PermService

同上

@Slf4j
@Service
public class PermService {

/**
* 模拟根据用户id查询返回用户的所有权限,实际查询语句参考:
* SELECT p.pval FROM perm p, role_perm rp, user_role ur
* WHERE p.pid = rp.perm_id AND ur.role_id = rp.role_id
* AND ur.user_id = #{userId}
*/
public Set<String> getPermsByUserId(Long uid) {
log.info("打印的 uid 为:{}", uid.toString());

Set<String> perms = new HashSet<>();
//三种编程语言代表三种角色:js程序员、java程序员、c++程序员
//js程序员的权限
perms.add("html:edit");
//c++程序员的权限
perms.add("hardware:debug");

//java程序员的权限
perms.add("mvn:install");
perms.add("mvn:clean");
perms.add("mvn:test");
return perms;
}
}

编写自定义的 Realm

/**
* 这个类是参照 JDBCRealm 写的,主要是自定义了如何查询用户信息,如何查询用户的角色和权限,如何校验密码等逻辑
*/
@Slf4j
@Setter(onMethod = @__({@Autowired}))
public class CustomRealm extends AuthorizingRealm {

private UserService userService;
private RoleService roleService;
private PermService permService;

//告诉shiro如何根据获取到的用户信息中的密码和盐值来校验密码
{
//设置用于匹配密码的 CredentialsMatcher
HashedCredentialsMatcher hashMatcher = new HashedCredentialsMatcher();
hashMatcher.setHashAlgorithmName(Sha256Hash.ALGORITHM_NAME);
hashMatcher.setStoredCredentialsHexEncoded(false);
hashMatcher.setHashIterations(1024);
this.setCredentialsMatcher(hashMatcher);
}

//定义如何获取用户的角色和权限的逻辑,给shiro做权限判断
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
//null usernames are invalid
if (principals == null) {
throw new AuthorizationException("PrincipalCollection method argument cannot be null.");
}

User user = (User) getAvailablePrincipal(principals);

SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
log.info("获取角色信息:{}", user.getRoles());
log.info("获取权限信息:{}", user.getPerms());

info.setRoles(user.getRoles());
info.setStringPermissions(user.getPerms());
return info;
}

//定义如何获取用户信息的业务逻辑,给shiro做登录
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

UsernamePasswordToken upToken = (UsernamePasswordToken) token;
String username = upToken.getUsername();

// Null username is invalid
if (username == null) {
throw new AccountException("Null usernames are not allowed by this realm.");
}

User userDB = userService.findUserByName(username);


if (userDB == null) {
throw new UnknownAccountException("No account found for admin [" + username + "]");
}

//查询用户的角色和权限存到SimpleAuthenticationInfo中,这样在其它地方
//SecurityUtils.getSubject().getPrincipal()就能拿出用户的所有信息,包括角色和权限
Set<String> roles = roleService.getRolesByUserId(userDB.getUid());
Set<String> perms = permService.getPermsByUserId(userDB.getUid());
userDB.getRoles().addAll(roles);
userDB.getPerms().addAll(perms);

SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(userDB, userDB.getPwd(), getName());
if (userDB.getSalt() != null) {
info.setCredentialsSalt(ByteSource.Util.bytes(userDB.getSalt()));
}

return info;
}

}

编写全局异常处理

/**
* 统一捕捉shiro的异常,返回给前台一个json信息,前台根据这个信息显示对应的提示,或者做页面的跳转。
*/
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {


@ExceptionHandler(ShiroException.class)
@ResponseBody
public ResponseEntity<String> handleShiroException(ShiroException e) {
String eName = e.getClass().getSimpleName();
log.error("shiro执行出错:{}", eName);
// return new Json(eName, false, Codes.SHIRO_ERR, "鉴权或授权过程出错", null);
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body("鉴权或授权过程出错");
}

....
}

完整的看项目源码

登录处理

在后台登录 url 中,接收用户名密码,据此创建一个 usernamePasswordToken 令牌,交由 Shiro 并调用 login() 方法进行登录,如果不抛出任何异常表明登录成功,如果抛出异常,这根据异常种类返回提示出错信息给用户。

这里直接抛出异常让全局异常处理~

/**
* @author alsritter
* @version 1.0
**/
@RestController
public class LoginController {

private static final Logger log = LoggerFactory.getLogger(LoginController.class);

/**
* 登录接口,由于 UserService 中是模拟返回用户信息的,
* 所以用户名随意,密码 123456
*/
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(@RequestBody String body) {

final String oper = "user login";
log.info("{}, body: {}", oper, body);

JSONObject json = JSON.parseObject(body);
final String uname = json.getString("uname");
final String pwd = json.getString("pwd");

if (ObjectUtils.isEmpty(uname)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of(oper, "用户名不能为空"));
}

if (ObjectUtils.isEmpty(pwd)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of(oper, "密码不能为空"));
}

final Subject currentUser = SecurityUtils.getSubject();
try {
// 登录, 它会自动记录登录信息
currentUser.login(new UsernamePasswordToken(uname, pwd));
// 从 session 取出用户信息
User user = (User) currentUser.getPrincipal();
if (user == null) throw new AuthenticationException();

// 返回登录用户的信息给前台,含用户的所有角色和权限
return ResponseEntity.ok().body(Map.of(
"uid", user.getUid(),
"nick", user.getNick(),
"roles", user.getRoles(),
"perms", user.getPerms()));

} catch (UnknownAccountException uae) {
log.warn("用户帐号不正确");
throw uae;

} catch (IncorrectCredentialsException ice) {
log.warn("用户密码不正确");
throw ice;

} catch (LockedAccountException lae) {
log.warn("用户帐号被锁定");
throw lae;
} catch (AuthenticationException ae) {
log.warn("登录出错", ae);
throw ae;
}
}

/**
* 未登录,shiro 应重定向到登录界面,此处返回未登录状态信息由前端控制跳转页面
*/
@RequestMapping("/un_auth")
public void unAuth() {
// return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("用户未登录!");
throw new UnauthenticatedException();
}

/**
* 登出
*/
@RequestMapping("/logout")
public ResponseEntity<String> logOut() {
SecurityUtils.getSubject().logout();
return ResponseEntity.ok().body("登出成功!");
}


/**
* 未授权,无权限,此处返回未授权状态信息由前端控制跳转页面
*/
@RequestMapping("/unauthorized")
public void unauthorized() {
// return ResponseEntity.status(HttpStatus.FORBIDDEN).body("用户无权限!");
throw new UnauthorizedException();
}
}

编写跨域过滤器

在 Springboot 中解决跨域有好几种方式,比如:

  • 使用 @CrossOrigin 注解
  • 实现 WebMvcConfigurer,然后重写它的 addCorsMappings 方法。

这两种方式在 springboot 中都能解决跨域的问题,但是在整合 shiro 后,跨域就失效了。原因是:shiro 的过滤器会在跨域处理之前执行,这就导致未允许跨域的请求先到达 shiro 过滤器,这样就会出现跨域错误。

所以需要自己处理跨域过滤

/**
* 这里继承自 UserFilter 用来配置 Shiro 跨域请求,避免 Shiro 拦截 OPTIONS 类型的请求
* 补充:浏览器先发送一个 OPTIONS 类型的请求(PreFlight)给后端,这种叫做预检请求,以检测实际请求是否可以被服务器所接收
* <p>
* 如果需要在其它场景使用跨域,例如匿名访问之类的,只需重写对应的过滤器,例如这个匿名访问过滤器是 AnonymousFilter
*
* @author alsritter
* @version 1.0
**/
@Slf4j
public class CorsAuthenticationFilter extends UserFilter {

//这个方法是判断是否能通过
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
log.debug("测试是否通过自定义跨域请求");
HttpServletRequest httpRequest = WebUtils.toHttp(request);
HttpServletResponse httpResponse = WebUtils.toHttp(response);
String method = WebUtils.toHttp(request).getMethod();
if ("OPTIONS".equalsIgnoreCase(method)) {
httpResponse.setHeader("Access-control-Allow-Origin", httpRequest.getHeader("Origin"));
httpResponse.setHeader("Access-Control-Allow-Methods", httpRequest.getMethod());
httpResponse.setHeader("Access-Control-Allow-Headers", httpRequest.getHeader("Access-Control-Request-Headers"));
httpResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}

配置 Shiro 过滤链

/**
* 把前面自定义的跨域拦截器加入到 Shiro 的过滤链里面
* 以及设置登陆
*
* @author alsritter
* @version 1.0
**/
@Configuration
public class ShiroWebFilter extends ShiroWebFilterConfiguration {


/**
* 注入 ShiroFilterFactoryBean 的 bean 名字必须是 shiroFilterFactoryBean ,不能填 shiroFilter
*/
@Override
protected ShiroFilterFactoryBean shiroFilterFactoryBean() {
// 采用父类的默认方法生成 shiroFilterFactoryBean
ShiroFilterFactoryBean shiroFilterFactoryBean = super.shiroFilterFactoryBean();
// 获取shiroFilterFactoryBean里的Filters集合
Map<String, Filter> filters = shiroFilterFactoryBean.getFilters();
filters.put("MyCorsFilter", new CorsAuthenticationFilter()); // 配置跨域拦截器
shiroFilterFactoryBean.setFilters(filters); // 把自定义的那个拦截器添加进去

// 过滤器链定义映射
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();

// anon: 所有url都都可以匿名访问,
// authc: 所有url都必须认证通过才可以访问;
// 过滤链定义,从上向下顺序执行,authc 应放在 anon 下面
filterChainDefinitionMap.put("/login", "anon");
// filterChainDefinitionMap.put("/html/**", "anon"); // 配置默认放行的资源

// 所有 url 都必须认证通过才可以访问,这里使用上面自定义的拦截器
filterChainDefinitionMap.put("/**", "MyCorsFilter");
// 配置退出 过滤器,其中的具体的退出代码 Shiro 已经替我们实现了, 位置放在 anon、authc下面
filterChainDefinitionMap.put("/logout", "logout");

// 如果不设置默认会自动寻找Web工程根目录下的"/login.jsp"页面
// 配器 shiro 认登录累面地址,前后端分离中登录累面跳转应由前端路由控制,后台仅返回 json 数据, 对应 LoginController 中 unauth 请求
shiroFilterFactoryBean.setLoginUrl("/un_auth");

// 登录成功后要跳转的链接, 此项目是前后端分离,故此行注释掉,登录成功之后返回用户基本信息及token给前端
// shiroFilterFactoryBean.setSuccessUrl("/index");

// 未授权界面, 对应LoginController中 unauthorized 请求
shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
}

编写配置类

编写 Shiro 配置类,注意看这里的 shiroFilterChainDefinition,后面几种授权也是改这个


@Configuration
@AllArgsConstructor
public class ShiroConfig {

/**
* 注入自定义的 realm,告诉 shiro 如何获取用户信息来做登录或权限控制
*/
@Bean
public Realm realm() {
return new CustomRealm();
}

@Bean
public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
/*
setUsePrefix(false) 用于解决一个奇怪的 bug。在引入 spring aop 的情况下。
在 @Controller 注解的类的方法中加入 @RequiresRole 注解,会导致该方法无法映射请求,导致返回 404。
加入这项配置能解决这个 bug
*/
creator.setUsePrefix(true);
return creator;
}

/**
* 不需要在此处配置权限页面,因为上面 ShiroFilterFactoryBean 已经配置过,但是此处必须存在
*/
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
return new DefaultShiroFilterChainDefinition();
}
}

使用注解控制鉴权授权

使用注解配置,来控制鉴权授权

/**
* 这个控制器用来测试权限
*
* @author alsritter
* @version 1.0
**/
@RestController
@RequestMapping("/t1")
public class Test1Controller {

// 由于TestController类上没有加@RequiresAuthentication注解,
// 不要求用户登录才能调用接口。所以hello()和a1()接口都是可以匿名访问的
@GetMapping("/hello")
public String hello() {
return "hello spring boot";
}

// 游客可访问,这个有点坑,游客的意思是指:subject.getPrincipal()==null
// 所以用户在未登录时subject.getPrincipal()==null,接口可访问
// 而用户登录后subject.getPrincipal()!=null,接口不可访问
@RequiresGuest
@GetMapping("/guest")
public String guest() {
return "@RequiresGuest";
}

// 已登录用户才能访问,这个注解比@RequiresUser更严格
// 如果用户未登录调用该接口,会抛出UnauthenticatedException
@RequiresAuthentication
@GetMapping("/authn")
public String authn() {
return "@RequiresAuthentication";
}

// 已登录用户或“记住我”的用户可以访问
// 如果用户未登录或不是“记住我”的用户调用该接口,UnauthenticatedException
@RequiresUser
@GetMapping("/user")
public String user() {
return "@RequiresUser";
}

// 要求登录的用户具有mvn:build权限才能访问
// 由于UserService模拟返回的用户信息中有该权限,所以这个接口可以访问
// 如果没有登录,UnauthenticatedException
@RequiresPermissions("mvn:install")
@GetMapping("/mvnInstall")
public String mvnInstall() {
return "mvn:install";
}

// 要求登录的用户具有mvn:build权限才能访问
// 由于UserService模拟返回的用户信息中【没有】该权限,所以这个接口【不可以】访问
// 如果没有登录,UnauthenticatedException
// 如果登录了,但是没有这个权限,会报错UnauthorizedException
@RequiresPermissions("gradleBuild")
@GetMapping("/gradleBuild")
public String gradleBuild() {
return "gradleBuild";
}

// 要求登录的用户具有js角色才能访问
// 由于UserService模拟返回的用户信息中有该角色,所以这个接口可访问
// 如果没有登录,UnauthenticatedException
@RequiresRoles("js")
@GetMapping("/js")
public String js() {
return "js programmer";
}

// 要求登录的用户具有js角色才能访问
// 由于UserService模拟返回的用户信息中有该角色,所以这个接口可访问
// 如果没有登录,UnauthenticatedException
// 如果登录了,但是没有该角色,会抛出UnauthorizedException
@RequiresRoles("python")
@GetMapping("/python")
public String python() {
return "python programmer";
}

}

Reference

Integrating Apache Shiro into Spring-Boot Applications Protecting a Spring Boot App with Apache Shiro Shiro用starter方式优雅整合到SpringBoot中